iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 28 篇

Day28 RPG5:回報任務與顯示背包的狀態機設計

  • 分享至 

  • xImage
  •  

到目前為止,我們已經可以和 NPC 對話接任務,以及撿拾藥草到背包了,剩下最後一片拼圖:可以和 NPC 對話完成任務。

預想的流程是這樣:

  1. 使用者在 P 四周按空白鍵開啟對話

  2. 清除地圖,顯示對話

    村民:
    年輕人,你回來啦?狀況如何?
    
    你:
    A: 我拿到 3 個草藥了。
    B: 我還沒去。
    
  3. 當使用者按 B 結束對話

  4. 當使用者按 A 顯示下一段對話

    村民:
    太感謝你了,這是我自己熬煮的雞湯,你帶著路上喝吧。
    
    你:
    A: 謝謝你。
    
  5. 使用者按 A 結束對話

雖然這個 RPG 任務的練習其實就只有到完成任務為止,但是因為我寫到這邊覺得通常遊戲裡到這邊應該會送點小東西?或者經驗值提升?

所以我在這裡寫了 NPC 會贈送雞湯給玩家,但是這就遇到另外一個問題:要怎麼顯示背包的內容有那些東西呢?

不過呢,一如我們一直在練習的:要問自己這是不是今天的任務呢?

今天的主要目的是要完成整個 RPG 遊戲到可以跑完我們先前訂下的目標,因此我們先不深入研究這個問題,先把這個問題記下來,明天我們還有一個篇幅的時間,我們就可以一起來解決這個問題。

狀態機設計

感覺似乎需要延續上一次設計的 NPC 狀態:

  1. status 狀態:擴充 NPC 後面的狀態
    • 尚未開始對話
    • 第一句對話
    • 第二句對話
    • 已接任務
    • 第三句對話
    • 第四句對話
    • 完成
  2. event 事件:我這邊只寫後半段的流程
    • 玩家按下空白鍵,從已接任務轉為第三句對話
    • 玩家按下 B ,從第三句對話轉到已接任務
    • 玩家按下 A,從第三句對話轉到第四句對話,或從第四句對話轉到完成

其實因為我想走最短路徑完成這個功能,所以目前我想先用這個方式來完成,但我內心總覺得它不是一個最適合的架構,如果有多個任務或是隨遊戲進展有不同的階段顯示不同的對話,該怎麼設計?

這個問題就一樣先記下來,不過由於篇幅的關係,這個系列中就不會去討論,留給大家自己來思考囉。

先補上狀態機設計圖:我只放後半部

https://ithelp.ithome.com.tw/upload/images/20261011/2018243968jExCmpYP.png

實作完成任務

這邊因為是已經寫過的東西,我就不拆解目標,直接動手。

首先新增變數:

const thirdTalk = [
    '村民:',
    '年輕人,你回來啦?狀況如何?',
    '',
    '你:',
    'A: 我拿到 3 個草藥了。',
    'B: 我還沒去。'
]
const fourthTalk = [
    '村民:',
    '太感謝你了,這是我自己熬煮的雞湯,你帶著路上喝吧。',
    '',
    '你:',
    'A: 當然可以。',
    'B: 抱歉,我趕時間。'
]

接著先處理按下空白鍵之後的邏輯,如果是未開始或者是已接下任務的話,就可以按空白鍵打開對話。

//在 openNPCTalk 內
if (isNext && (npcStatus === 'notStarted' || npcStatus === 'getTask')) {

        // 是的話就清除畫面
        process.stdout.write(`\x1b[${viewHeight}A`); // 上移游標
        process.stdout.write('\x1b[J'); // 清除畫面
        process.stdout.write("\n".repeat(firstTalk.length));//推出空間

        // 顯示對話畫面

        let frame = '';

        if (npcStatus === 'notStarted') {

            npcStatus = 'firstTalk';

            for (let i = 0; i < firstTalk.length; i++) {
                frame += firstTalk[i] + '\n'
            }

            process.stdout.write(`\x1b[${firstTalk.length}A`);//上移游標

        } else if (npcStatus === 'getTask') {

            npcStatus = 'thirdTalk';

            for (let i = 0; i < thirdTalk.length; i++) {
                frame += thirdTalk[i] + '\n'
            }

            process.stdout.write(`\x1b[${thirdTalk.length}A`);//上移游標

        }

        process.stdout.write(frame);//印出

    }

然後去處理按下 A 和 B 的監聽,把新加入的狀態加進來做判斷:

// 按下 A 按鍵
if ((key === 'a' || key === 'A') && (npcStatus === 'firstTalk' || npcStatus === 'secondTalk' || npcStatus === 'thirdTalk' || npcStatus === 'forthTalk' )) {
  pushNPCStatus();
  return;
}

// 按下 B 按鍵
if ((key === 'b' || key === 'B') && (npcStatus === 'firstTalk' || npcStatus === 'secondTalk' || npcStatus === 'thirdTalk')) {
   npcStatus = 'notStarted';

   // 按下 B 後回到遊戲畫面
   process.stdout.write(`\x1b[${firstTalk.length}A`);
   process.stdout.write('\x1b[J');
   process.stdout.write('\n'.repeat(viewHeight));
   render();

   return;
}

最後是處理按下 A 之後的這個 pushNPCStatus 的邏輯,加入第三段對話切換到第四段對話,以及第四段對話完成的邏輯,其實都是複製前面的下來改。

 else if (npcStatus === "secondTalk") {
        npcStatus = "getTask";
        task += 1;

        // 清除畫面
        process.stdout.write(`\x1b[${secondTalk.length}A`); // 上移游標
        process.stdout.write('\x1b[J'); // 清除畫面
        process.stdout.write('\n'.repeat(viewHeight));//推出空間
        render();
    } else if (npcStatus === "thirdTalk") {

        npcStatus = "forthTalk";

        // 清除畫面
        process.stdout.write(`\x1b[${thirdTalk.length}A`); // 上移游標
        process.stdout.write('\x1b[J'); // 清除畫面
        process.stdout.write("\n".repeat(fourthTalk.length));//推出空間

        // 顯示對話畫面

        let frame = '';
        for (let i = 0; i < fourthTalk.length; i++) {
            frame += fourthTalk[i] + '\n'
        }

        process.stdout.write(`\x1b${fourthTalk.length}A`);//上移游標
        process.stdout.write(frame);//印出
    } else if (npcStatus === "forthTalk") {
        npcStatus = "complete";
        task -=1;

        // 清除畫面
        process.stdout.write(`\x1b[${fourthTalk.length}A`); // 上移游標
        process.stdout.write('\x1b[J'); // 清除畫面
        process.stdout.write('\n'.repeat(viewHeight));//推出空間
        render();
    }

比較容易忘記的是這邊要將第二段對話按 A 之後的狀態改為 getTask ,然後加上兩段新的邏輯。

到這邊我們完成整個對話的切換了,但還沒有完成檢查有沒有草藥和扣除草藥的邏輯,我們還是先印出來檢查切換正不正常:

1

現在切換流程是正常的,而且也已經在側邊欄將任務數字扣除了,但是我們要在扣掉任務之前,先做判斷,這是我們前面沒有擬到的流程,我們要先停下來想一想希望怎麼呈現這個部分:

  1. 當草藥數量不夠,顯示錯誤訊息
  2. 當草藥數量不夠,直接不顯示 A 回答?

感覺一般線上遊戲都是直接不顯示 A 回答?

所以如果要補充狀態機會怎麼做呢?

https://ithelp.ithome.com.tw/upload/images/20261011/20182439mR4FsoXibp.png

我試著調整成這樣,可能不是正規的繪製方法,但我想重點在於試著利用這個方式去釐清自己的想法。

那麼就來實作判斷之後決定要不要印出回答的邏輯

else if (npcStatus === 'getTask') {
  npcStatus = 'thirdTalk';

  for (let i = 0; i < thirdTalk.length; i++) {
    if (bag < 3 && i === 4){
       continue;
    }
    
    frame += thirdTalk[i] + '\n'
}

測試看看

2

發現有成功隱藏,但是 A 還是可以按,所以我們要阻止按 A,順便這時候把背包的草藥扣三。

 // 在 pushNPCStatus 內的這一段
 else if (npcStatus === "thirdTalk") {

        // 加上這個
        if (bag < 3){
            return;
        }
        
        npcStatus = "forthTalk";
        bag -= 3 ; // 扣掉三
        
        // 後面省略
}

另外我也發現 render 需要加上第三個對話和第四個對話時阻擋重繪地圖。

if (npcStatus === 'firstTalk' || npcStatus === 'secondTalk' || npcStatus === 'thirdTalk' || npcStatus === 'forthTalk') {
  return;
}

來玩一次整個遊戲看看吧!!

4

確定可以跑完一遍整個流程了!雖然說我自己多嘗試了幾次發現有一些 bug ,像是某些部分的重新渲染沒有被阻擋到之類的,但是呢,今天的工作邊界還是要踩穩!先完成到這邊, debug 的部分就交給未來的自己。

你還有觀察到哪些問題呢?試著想想該怎麼解決吧!明天見囉。

如果你想要瀏覽完整的程式碼,可以參考這裡:第 28 天程式碼


上一篇
Day27 RPG6:撿拾藥草到背包的狀態機練習
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區! 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言